Skip to content

fix(native): resolve declarations in cascade order once an element's variables are set - #474

Open
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/deferred-declarations
Open

YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/deferred-declarations

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A declaration whose value reads a var() resolves after every other declaration of its element, through a placeholder left at its key. Three defects on main follow from that:

  1. It resolves against the wrong target. Its closure captured applyDeclarations's shared target binding, so it checked its placeholder against whatever nested target the last declaration ended on, missed it, and left the placeholder in the style. color: var(--c); box-shadow: 1px 1px blue renders color: { color: true }, text-shadow does the same, -webkit-line-clamp: var(--n) beside a deferred colour renders numberOfLines: { numberOfLines: true }, and rotate: 45deg beside a box-shadow leaves { rotateZ: true } in the transform and writes the rotation into the shadow.
  2. A deferred shorthand overwrites a later declaration. border: var(--b); border-color: blue paints the variable's colour, within a rule and across rules. border-inline-width: var(--w) does the same over a later border-inline-start-width, an earlier rule's text-shadow: var(--s) replaces a later rule's text-shadow, and animation: var(--a) replaces a later animation-duration.
  3. An individual transform property drops a deferred transform. It replaces the placeholder with an empty list, so .a { transform: var(--t) } .b { rotate: 45deg } renders the rotation alone, where browsers apply both.

Solution

  • Every rule's variables are assigned before any declaration resolves, since a var() substitutes the element's computed custom property (css-variables-1 §3). Then every declaration resolves in cascade order, so a later declaration overwrites an earlier one as browsers apply them.
  • The placeholder is left only for an individual transform property, which composes into the transform list once every rule is walked. Its closure keeps its own declaration's target.
  • A runtime value that resolves to nothing still removes the property, as it does on main.

Tests

  • src/__tests__/native/deferred-declarations.test.tsx:
    • each placeholder shape, and a shadow that omits its colour beside a colour;
    • each shorthand before and after its longhand, in one rule and across rules;
    • a deferred transform beside an individual transform property;
    • an individual transform property before and after a transform, and beside a box-shadow;
    • a var() with no value, and a written value the runtime cannot read, over an earlier rule's value.
  • src/__tests__/native/animations.test.tsx: a written animation-duration after a variable animation, with the variable inlined and kept.
  • On main, 14 of these 22 cases fail; the 8 that pass there are the orders main already gets right, kept so they stay right.
  • Each of 6 mutations of the walk fails its own cases.
  • Every expectation is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS, apart from the platform function, which this library defines.

Verification

On Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes.

Device evidence, before and after: #474 (comment)

Merge order

#393 and #501 build on this, so this lands first.

It shares lines with #389, #391, #411 and #488 (native/styles/calculate-props.ts); whichever lands second rebases. With #391, a declaration a var() leaves invalid at computed-value time resolves to unset, so the second of the two to land keeps that and drops this removal for a runtime value that resolves to nothing, which leaves one the runtime cannot read ignored.

Base

Branched off main (a5002c5).

applyDeclarations reassigned its target parameter per declaration, and a
deferred declaration's closure captured that shared binding. The closure
runs after every declaration in the rule has been walked, so it read
whatever nested target the last declaration ended on: a deferred color
beside a box-shadow checked its placeholder against the shadow entry,
missed it, and left the internal { color: true } in the style.
-webkit-line-clamp: var(--n) left { numberOfLines: true } on the prop the
same way.

Each declaration now walks into a binding of its own.
A deferred declaration resolves after every other declaration of the
element, so a shorthand that resolved at runtime spread its keys over
whatever a later declaration had set: border: var(--b) followed by
border-color: blue painted the variable's colour, within a rule and across
rules, and so did border-inline-width: var(--w) over a later
border-inline-start-width. Each key belongs to the declaration that set it
last (css-cascade-5 §6.4).

Each element now records, per target and key, the cascade position of its
last writer. A deferred shorthand learns its keys only when it resolves, so
it claims them one by one and keeps the ones a later declaration holds.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The shorthand bar is edged RED, the text keeps the default ink, and the first tile is an upright square. A var()-valued declaration resolves after every other one, so the border overwrites the later border-color, the colour lands on the wrong target, and the scale replaces the turn.

FIXED — The shorthand bar is edged BLUE like its control, the text is RED, and both tiles are diamonds. The later border-color wins, the colour resolves on its own target, and the turn composes with the scale.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run; the second tile writes the turn out and is the control. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

A shadow that omits its colour takes currentcolor, which resolves as a
deferred declaration inside the shadow. On main its placeholder reaches
the style: textShadowColor: { textShadowColor: true } for a colourless
text-shadow beside a color, and color: { color: true } on a colourless
box-shadow entry under an inherited colour.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 16, 2026 14:04
A later `text-shadow` writes its offset through `textShadowOffset.width`
and `.height`, and only the leaf was claimed, so an earlier
`text-shadow: var()` that resolved late still replaced `textShadowOffset`
whole: `.earlier { --s: 2px 2px blue; text-shadow: var(--s) }` before
`.my-class { text-shadow: 1px 1px red }` drew the red shadow at the blue
offset. A declaration written through a path now claims each key it
passes through.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 14:13
The two text-shadow rows are about which declaration wins, so the
colour a variable carries is written in the form a runtime reader keeps
whether it serialises a named colour or not.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 17, 2026 01:09
…ent's variables are assigned

A declaration that reads a var() was resolved after the walk, through a
placeholder at its key, so the walk had to remember which later declaration
set each key to keep a deferred shorthand off it. A placeholder is also what
an individual transform property replaced with an empty list, so
`.a { transform: var(--t) } .b { rotate: 45deg }` lost the transform.

Every rule's variables are now assigned before any declaration resolves, and
every declaration resolves in cascade order, so a later declaration overwrites
an earlier one as the browsers apply them and the key claims are gone. A
placeholder is left only for an individual transform property, which composes
into the transform list once every rule is walked. A runtime value that
resolves to nothing still resets the property.
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): resolve a deferred declaration on its own target, in cascade order fix(native): resolve every declaration in cascade order once the element's variables are assigned Oct 17, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 17, 2026 03:15
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): resolve every declaration in cascade order once the element's variables are assigned fix(native): resolve declarations in cascade order once an element's variables are set Oct 17, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant